Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.64% covered (warning)
84.64%
292 / 345
81.44% covered (warning)
81.44%
79 / 97
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryBuilder
84.64% covered (warning)
84.64%
292 / 345
81.44% covered (warning)
81.44%
79 / 97
219.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
1
 getConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDialect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setConnectionRouter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 forceWrite
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 switchToReadConnection
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
7.23
 switchToWriteConnection
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 restoreConnection
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
5.67
 updateComponents
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 table
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 from
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 with
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 withRecursive
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 withMaterialized
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getCteManager
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 union
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 unionAll
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 intersect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 except
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUnions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 distinct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 distinctOn
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 isDistinct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDistinctOn
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prefix
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setPrefix
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 select
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
 getOne
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
5.58
 getColumn
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
6.97
 getValue
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
6.97
 insert
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 insertMulti
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 replace
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 replaceMulti
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 truncate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 merge
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 batch
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 each
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 stream
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
6.60
 where
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
 andWhere
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 orWhere
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 having
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 orHaving
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 whereIn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 whereNotIn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 whereExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 whereNotExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 whereRaw
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 havingRaw
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 exists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 notExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tableExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 join
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 leftJoin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rightJoin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 innerJoin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 lateralJoin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 orderBy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 groupBy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 cache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 noCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 limit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 offset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 option
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 asObject
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onDuplicate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 toSQL
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 explain
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 explainAnalyze
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 explainAdvice
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 describe
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 indexes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 keys
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 constraints
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 executeStatement
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fetchAll
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadCsv
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadXml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 selectJson
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 whereJsonPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 whereJsonContains
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 jsonSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonRemove
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 orderByJson
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 whereJsonExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 paginate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 simplePaginate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 cursorPaginate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 integrateJsonSelectionsAndOrders
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
6.74
1<?php
2
3declare(strict_types=1);
4
5namespace tommyknocker\pdodb\query;
6
7use Closure;
8use Generator;
9use InvalidArgumentException;
10use PDOException;
11use PDOStatement;
12use RuntimeException;
13use tommyknocker\pdodb\cache\CacheManager;
14use tommyknocker\pdodb\connection\ConnectionInterface;
15use tommyknocker\pdodb\connection\ConnectionRouter;
16use tommyknocker\pdodb\dialects\DialectInterface;
17use tommyknocker\pdodb\helpers\values\RawValue;
18use tommyknocker\pdodb\query\cte\CteDefinition;
19use tommyknocker\pdodb\query\cte\CteManager;
20use tommyknocker\pdodb\query\interfaces\BatchProcessorInterface;
21use tommyknocker\pdodb\query\interfaces\ConditionBuilderInterface;
22use tommyknocker\pdodb\query\interfaces\DmlQueryBuilderInterface;
23use tommyknocker\pdodb\query\interfaces\ExecutionEngineInterface;
24use tommyknocker\pdodb\query\interfaces\FileLoaderInterface;
25use tommyknocker\pdodb\query\interfaces\JoinBuilderInterface;
26use tommyknocker\pdodb\query\interfaces\JsonQueryBuilderInterface;
27use tommyknocker\pdodb\query\interfaces\ParameterManagerInterface;
28use tommyknocker\pdodb\query\interfaces\QueryBuilderInterface;
29use tommyknocker\pdodb\query\interfaces\SelectQueryBuilderInterface;
30
31class QueryBuilder implements QueryBuilderInterface
32{
33    /* ---------------- Construction / meta ---------------- */
34
35    /** @var ConnectionInterface Database connection instance */
36    protected ConnectionInterface $connection;
37
38    /** @var DialectInterface Dialect instance for database-specific SQL */
39    protected DialectInterface $dialect;
40
41    /** @var string|null table name */
42    protected ?string $table = null {
43        get {
44            if (!$this->table) {
45                throw new RuntimeException('You must define table first. Use table() or from() methods');
46            }
47            return $this->table;
48        }
49    }
50
51    /** @var string|null Table prefix */
52    protected ?string $prefix = null;
53
54    /** @var CacheManager|null Cache manager for query result caching */
55    protected ?CacheManager $cacheManager = null;
56
57    /** @var \tommyknocker\pdodb\query\cache\QueryCompilationCache|null Query compilation cache */
58    protected ?\tommyknocker\pdodb\query\cache\QueryCompilationCache $compilationCache = null;
59
60    /** @var ConnectionRouter|null Connection router for read/write splitting */
61    protected ?ConnectionRouter $connectionRouter = null;
62
63    /** @var bool Force next query to use write connection */
64    protected bool $forceWriteConnection = false;
65
66    /** @var CteManager|null CTE manager for Common Table Expressions */
67    protected ?CteManager $cteManager = null;
68
69    /** @var array<UnionQuery> Array of UNION/INTERSECT/EXCEPT operations */
70    protected array $unions = [];
71
72    /** @var bool Whether to use DISTINCT */
73    protected bool $distinct = false;
74
75    /** @var array<string> Columns for DISTINCT ON (PostgreSQL) */
76    protected array $distinctOn = [];
77
78    // Component instances
79    protected ParameterManagerInterface $parameterManager;
80    protected ExecutionEngineInterface $executionEngine;
81    protected ConditionBuilderInterface $conditionBuilder;
82    protected JoinBuilderInterface $joinBuilder;
83    protected SelectQueryBuilderInterface $selectQueryBuilder;
84    protected DmlQueryBuilderInterface $dmlQueryBuilder;
85    protected BatchProcessorInterface $batchProcessor;
86    protected JsonQueryBuilderInterface $jsonQueryBuilder;
87    protected FileLoaderInterface $fileLoader;
88
89    /**
90     * QueryBuilder constructor.
91     *
92     * @param ConnectionInterface $connection
93     * @param string $prefix
94     * @param CacheManager|null $cacheManager
95     * @param \tommyknocker\pdodb\query\cache\QueryCompilationCache|null $compilationCache
96     * @param QueryProfiler|null $profiler
97     */
98    public function __construct(
99        ConnectionInterface $connection,
100        string $prefix = '',
101        ?CacheManager $cacheManager = null,
102        ?\tommyknocker\pdodb\query\cache\QueryCompilationCache $compilationCache = null,
103        ?QueryProfiler $profiler = null
104    ) {
105        $this->connection = $connection;
106        $this->dialect = $connection->getDialect();
107        $this->prefix = $prefix;
108        $this->cacheManager = $cacheManager;
109        $this->compilationCache = $compilationCache;
110
111        // Initialize components with shared parameter manager and raw value resolver
112        $this->parameterManager = new ParameterManager();
113        $rawValueResolver = new RawValueResolver($connection, $this->parameterManager);
114        $this->executionEngine = new ExecutionEngine($connection, $rawValueResolver, $this->parameterManager, $profiler);
115        $this->conditionBuilder = new ConditionBuilder(
116            $connection,
117            $this->parameterManager,
118            $this->executionEngine,
119            $rawValueResolver
120        );
121        $this->joinBuilder = new JoinBuilder($connection, $rawValueResolver, $this->parameterManager);
122        $this->selectQueryBuilder = new SelectQueryBuilder(
123            $connection,
124            $this->parameterManager,
125            $this->executionEngine,
126            $this->conditionBuilder,
127            $this->joinBuilder,
128            $rawValueResolver,
129            $cacheManager,
130            $this->compilationCache
131        );
132        $this->dmlQueryBuilder = new DmlQueryBuilder(
133            $connection,
134            $this->parameterManager,
135            $this->executionEngine,
136            $this->conditionBuilder,
137            $rawValueResolver,
138            $this->joinBuilder
139        );
140        $this->batchProcessor = new BatchProcessor(
141            $connection,
142            $this->executionEngine,
143            $this->parameterManager,
144            $rawValueResolver
145        );
146        $this->jsonQueryBuilder = new JsonQueryBuilder(
147            $connection,
148            $this->parameterManager,
149            $this->conditionBuilder,
150            $rawValueResolver
151        );
152        $this->fileLoader = new FileLoader($connection);
153
154        // Set initial state
155        $this->setPrefix($prefix);
156    }
157
158    /**
159     * Return active connection instance.
160     *
161     * @return ConnectionInterface
162     */
163    public function getConnection(): ConnectionInterface
164    {
165        return $this->connection;
166    }
167
168    /**
169     * Return dialect instance used by this builder.
170     *
171     * @return DialectInterface
172     */
173    public function getDialect(): DialectInterface
174    {
175        return $this->dialect;
176    }
177
178    /**
179     * Return table prefix configured for this builder.
180     *
181     * @return string|null
182     */
183    public function getPrefix(): ?string
184    {
185        return $this->prefix;
186    }
187
188    /**
189     * Set connection router for read/write splitting.
190     *
191     * @param ConnectionRouter|null $router
192     *
193     * @return static
194     */
195    public function setConnectionRouter(?ConnectionRouter $router): static
196    {
197        $this->connectionRouter = $router;
198        return $this;
199    }
200
201    /**
202     * Force next read query to use write connection.
203     *
204     * @return static
205     */
206    public function forceWrite(): static
207    {
208        $this->forceWriteConnection = true;
209        return $this;
210    }
211
212    /**
213     * Switch to read connection if router is available.
214     *
215     * @return ConnectionInterface|null Original connection before switch
216     */
217    protected function switchToReadConnection(): ?ConnectionInterface
218    {
219        if ($this->connectionRouter === null) {
220            return null;
221        }
222
223        $originalConnection = $this->connection;
224
225        // Get appropriate connection based on forceWrite flag
226        if ($this->forceWriteConnection) {
227            $this->connection = $this->connectionRouter->getWriteConnection();
228            $this->forceWriteConnection = false; // Reset flag after use
229        } else {
230            $this->connection = $this->connectionRouter->getReadConnection();
231        }
232
233        // Update components with new connection
234        $this->updateComponents();
235
236        return $originalConnection;
237    }
238
239    /**
240     * Switch to write connection if router is available.
241     *
242     * @return ConnectionInterface|null Original connection before switch
243     */
244    protected function switchToWriteConnection(): ?ConnectionInterface
245    {
246        if ($this->connectionRouter === null) {
247            return null;
248        }
249
250        $originalConnection = $this->connection;
251        $this->connection = $this->connectionRouter->getWriteConnection();
252
253        // Update components with new connection
254        $this->updateComponents();
255
256        return $originalConnection;
257    }
258
259    /**
260     * Restore original connection.
261     *
262     * @param ConnectionInterface|null $originalConnection
263     */
264    protected function restoreConnection(?ConnectionInterface $originalConnection): void
265    {
266        if ($originalConnection !== null && $this->connectionRouter !== null) {
267            $this->connection = $originalConnection;
268            $this->updateComponents();
269        }
270    }
271
272    /**
273     * Update all components with current connection.
274     *
275     * Note: This method minimally updates components to preserve state.
276     * We only update the execution engine which directly uses the connection.
277     */
278    protected function updateComponents(): void
279    {
280        $this->dialect = $this->connection->getDialect();
281        $rawValueResolver = new RawValueResolver($this->connection, $this->parameterManager);
282
283        // Only update execution engine to use new connection
284        // Keep other components (conditionBuilder, etc.) to preserve their state
285        $this->executionEngine = new ExecutionEngine($this->connection, $rawValueResolver, $this->parameterManager);
286
287        // Update execution engine reference in condition builder
288        // This is done via property access if the builder supports it
289    }
290
291    /* ---------------- Table / source ---------------- */
292
293    /**
294     * Sets the table to query.
295     *
296     * @param string $table The table to query.
297     *
298     * @return static The current instance.
299     */
300    public function table(string $table): static
301    {
302        return $this->from($table);
303    }
304
305    /**
306     * Sets the table to query.
307     *
308     * @param string $table The table to query.
309     *
310     * @return static The current instance.
311     */
312    public function from(string $table): static
313    {
314        $this->table = $table;
315        $this->selectQueryBuilder->setTable($table);
316        $this->dmlQueryBuilder->setTable($table);
317        $this->fileLoader->setTable($table);
318        return $this;
319    }
320
321    /**
322     * Add a Common Table Expression (CTE) to the query.
323     *
324     * @param string $name CTE name
325     * @param QueryBuilder|Closure(QueryBuilder): void|string|RawValue $query Query builder, closure, or raw SQL
326     * @param array<string> $columns Optional explicit column list
327     *
328     * @return static The current instance.
329     */
330    public function with(
331        string $name,
332        QueryBuilder|Closure|string|RawValue $query,
333        array $columns = []
334    ): static {
335        if ($this->cteManager === null) {
336            $this->cteManager = new CteManager($this->connection);
337        }
338
339        // Convert RawValue to string
340        if ($query instanceof RawValue) {
341            $query = $query->getValue();
342        }
343
344        $cte = new CteDefinition($name, $query, false, false, $columns);
345        $this->cteManager->add($cte);
346
347        return $this;
348    }
349
350    /**
351     * Add a recursive Common Table Expression (CTE) to the query.
352     *
353     * @param string $name CTE name
354     * @param QueryBuilder|Closure(QueryBuilder): void|string|RawValue $query Query with UNION ALL structure
355     * @param array<string> $columns Explicit column list (recommended for recursive CTEs)
356     *
357     * @return static The current instance.
358     */
359    public function withRecursive(
360        string $name,
361        QueryBuilder|Closure|string|RawValue $query,
362        array $columns = []
363    ): static {
364        if ($this->cteManager === null) {
365            $this->cteManager = new CteManager($this->connection);
366        }
367
368        // Convert RawValue to string
369        if ($query instanceof RawValue) {
370            $query = $query->getValue();
371        }
372
373        $cte = new CteDefinition($name, $query, true, false, $columns);
374        $this->cteManager->add($cte);
375
376        return $this;
377    }
378
379    /**
380     * Add a materialized Common Table Expression (CTE) to the query.
381     *
382     * Materialized CTEs cache the result set, improving performance for expensive queries
383     * that are referenced multiple times. The result is computed once and stored in memory.
384     *
385     * Supported databases:
386     * - PostgreSQL: Uses MATERIALIZED keyword (PostgreSQL 12+)
387     * - MySQL: Uses optimizer hints (MySQL 8.0+)
388     * - SQLite: Not supported (throws RuntimeException)
389     *
390     * @param string $name CTE name
391     * @param QueryBuilder|Closure(QueryBuilder): void|string|RawValue $query Query builder, closure, or raw SQL
392     * @param array<string> $columns Explicit column list (optional)
393     *
394     * @return static The current instance.
395     * @throws \RuntimeException If database dialect does not support materialized CTEs
396     */
397    public function withMaterialized(
398        string $name,
399        QueryBuilder|Closure|string|RawValue $query,
400        array $columns = []
401    ): static {
402        if ($this->cteManager === null) {
403            $this->cteManager = new CteManager($this->connection);
404        }
405
406        // Convert RawValue to string
407        if ($query instanceof RawValue) {
408            $query = $query->getValue();
409        }
410
411        $cte = new CteDefinition($name, $query, false, true, $columns);
412        $this->cteManager->add($cte);
413
414        return $this;
415    }
416
417    /**
418     * Get CTE manager instance.
419     *
420     * @return CteManager|null CTE manager or null if not initialized.
421     */
422    public function getCteManager(): ?CteManager
423    {
424        return $this->cteManager;
425    }
426
427    /**
428     * Add UNION operation.
429     *
430     * @param QueryBuilder|Closure(QueryBuilder): void $query Query to union.
431     *
432     * @return static The current instance.
433     */
434    public function union(QueryBuilder|Closure $query): static
435    {
436        $this->unions[] = new UnionQuery('UNION', $query);
437        return $this;
438    }
439
440    /**
441     * Add UNION ALL operation.
442     *
443     * @param QueryBuilder|Closure(QueryBuilder): void $query Query to union.
444     *
445     * @return static The current instance.
446     */
447    public function unionAll(QueryBuilder|Closure $query): static
448    {
449        $this->unions[] = new UnionQuery('UNION ALL', $query);
450        return $this;
451    }
452
453    /**
454     * Add INTERSECT operation.
455     *
456     * @param QueryBuilder|Closure(QueryBuilder): void $query Query to intersect.
457     *
458     * @return static The current instance.
459     */
460    public function intersect(QueryBuilder|Closure $query): static
461    {
462        $this->unions[] = new UnionQuery('INTERSECT', $query);
463        return $this;
464    }
465
466    /**
467     * Add EXCEPT operation.
468     *
469     * @param QueryBuilder|Closure(QueryBuilder): void $query Query to except.
470     *
471     * @return static The current instance.
472     */
473    public function except(QueryBuilder|Closure $query): static
474    {
475        $this->unions[] = new UnionQuery('EXCEPT', $query);
476        return $this;
477    }
478
479    /**
480     * Get union operations.
481     *
482     * @return array<UnionQuery>
483     */
484    public function getUnions(): array
485    {
486        return $this->unions;
487    }
488
489    /**
490     * Enable DISTINCT for the query.
491     *
492     * @return static The current instance.
493     */
494    public function distinct(): static
495    {
496        $this->distinct = true;
497        return $this;
498    }
499
500    /**
501     * Enable DISTINCT ON for specific columns.
502     * Note: Currently supported only in PostgreSQL. Will throw exception for other databases.
503     *
504     * @param string|array<string> $columns Column(s) for DISTINCT ON.
505     *
506     * @return static The current instance.
507     * @throws RuntimeException If database does not support DISTINCT ON.
508     */
509    public function distinctOn(string|array $columns): static
510    {
511        if (!$this->dialect->supportsDistinctOn()) {
512            throw new RuntimeException(
513                'DISTINCT ON is not supported by ' . get_class($this->dialect)
514            );
515        }
516
517        $this->distinctOn = is_array($columns) ? $columns : [$columns];
518        return $this;
519    }
520
521    /**
522     * Check if DISTINCT is enabled.
523     *
524     * @return bool
525     */
526    public function isDistinct(): bool
527    {
528        return $this->distinct;
529    }
530
531    /**
532     * Get DISTINCT ON columns.
533     *
534     * @return array<string>
535     */
536    public function getDistinctOn(): array
537    {
538        return $this->distinctOn;
539    }
540
541    /**
542     * Sets the prefix for table names.
543     *
544     * @param string $prefix The prefix for table names.
545     *
546     * @return static The current instance.
547     */
548    public function prefix(string $prefix): static
549    {
550        $this->prefix = $prefix;
551        $this->setPrefix($prefix);
552        return $this;
553    }
554
555    /**
556     * Set prefix for all components.
557     *
558     * @param string|null $prefix
559     */
560    protected function setPrefix(?string $prefix): void
561    {
562        $this->selectQueryBuilder->setPrefix($prefix);
563        $this->dmlQueryBuilder->setPrefix($prefix);
564        $this->conditionBuilder->setPrefix($prefix);
565        $this->joinBuilder->setPrefix($prefix);
566        $this->fileLoader->setPrefix($prefix);
567    }
568
569    /* ---------------- Select / projection ---------------- */
570
571    /**
572     * Adds columns to the SELECT clause.
573     *
574     * @param RawValue|callable(QueryBuilderInterface): void|string|array<int|string, string|RawValue|callable(QueryBuilderInterface): void> $cols The columns to add.
575     *
576     * @return static The current instance.
577     */
578    public function select(RawValue|callable|string|array $cols): static
579    {
580        $this->selectQueryBuilder->select($cols);
581        return $this;
582    }
583
584    /**
585     * Execute SELECT statement and return all rows.
586     *
587     * @return array<int, array<string, mixed>>
588     * @throws PDOException
589     */
590    public function get(): array
591    {
592        $originalConnection = $this->switchToReadConnection();
593
594        try {
595            // Integrate JSON selections before executing query
596            $jsonSelects = $this->jsonQueryBuilder->getJsonSelects();
597            if (!empty($jsonSelects)) {
598                $this->selectQueryBuilder->select($jsonSelects);
599                // Clear JSON selections after integration to avoid duplication
600                $this->jsonQueryBuilder->clearJsonSelects();
601            }
602
603            // Integrate JSON order expressions before executing query
604            $jsonOrders = $this->jsonQueryBuilder->getJsonOrders();
605            if (!empty($jsonOrders)) {
606                foreach ($jsonOrders as $orderExpr) {
607                    // JSON order expressions already contain direction, so add them directly
608                    $this->selectQueryBuilder->addOrderExpression($orderExpr);
609                }
610                // Clear JSON orders after integration to avoid duplication
611                $this->jsonQueryBuilder->clearJsonOrders();
612            }
613
614            // Set CTE manager before building query
615            if ($this->cteManager !== null) {
616                $this->selectQueryBuilder->setCteManager($this->cteManager);
617            }
618
619            // Set UNION operations
620            if (!empty($this->unions)) {
621                $this->selectQueryBuilder->setUnions($this->unions);
622            }
623
624            // Set DISTINCT
625            if ($this->distinct) {
626                $this->selectQueryBuilder->setDistinct(true);
627            }
628
629            // Set DISTINCT ON
630            if (!empty($this->distinctOn)) {
631                $this->selectQueryBuilder->setDistinctOn($this->distinctOn);
632            }
633
634            return $this->selectQueryBuilder->get();
635        } finally {
636            $this->restoreConnection($originalConnection);
637        }
638    }
639
640    /**
641     * Execute SELECT statement and return first row.
642     *
643     * @return mixed
644     * @throws PDOException
645     */
646    public function getOne(): mixed
647    {
648        $originalConnection = $this->switchToReadConnection();
649
650        try {
651            // Integrate JSON selections before executing query
652            $jsonSelects = $this->jsonQueryBuilder->getJsonSelects();
653            if (!empty($jsonSelects)) {
654                $this->selectQueryBuilder->select($jsonSelects);
655                // Clear JSON selections after integration to avoid duplication
656                $this->jsonQueryBuilder->clearJsonSelects();
657            }
658
659            // Integrate JSON order expressions before executing query
660            $jsonOrders = $this->jsonQueryBuilder->getJsonOrders();
661            if (!empty($jsonOrders)) {
662                foreach ($jsonOrders as $orderExpr) {
663                    // JSON order expressions already contain direction, so add them directly
664                    $this->selectQueryBuilder->addOrderExpression($orderExpr);
665                }
666                // Clear JSON orders after integration to avoid duplication
667                $this->jsonQueryBuilder->clearJsonOrders();
668            }
669
670            // Set CTE manager before building query
671            if ($this->cteManager !== null) {
672                $this->selectQueryBuilder->setCteManager($this->cteManager);
673            }
674
675            return $this->selectQueryBuilder->getOne();
676        } finally {
677            $this->restoreConnection($originalConnection);
678        }
679    }
680
681    /**
682     * Execute SELECT statement and return column values.
683     *
684     * @return array<int, mixed>
685     * @throws PDOException
686     */
687    public function getColumn(): array
688    {
689        $originalConnection = $this->switchToReadConnection();
690
691        try {
692            // Integrate JSON selections before executing query
693            $jsonSelects = $this->jsonQueryBuilder->getJsonSelects();
694            if (!empty($jsonSelects)) {
695                $this->selectQueryBuilder->select($jsonSelects);
696                // Clear JSON selections after integration to avoid duplication
697                $this->jsonQueryBuilder->clearJsonSelects();
698            }
699
700            // Integrate JSON order expressions before executing query
701            $jsonOrders = $this->jsonQueryBuilder->getJsonOrders();
702            if (!empty($jsonOrders)) {
703                foreach ($jsonOrders as $orderExpr) {
704                    // JSON order expressions already contain direction, so add them directly
705                    $this->selectQueryBuilder->addOrderExpression($orderExpr);
706                }
707                // Clear JSON orders after integration to avoid duplication
708                $this->jsonQueryBuilder->clearJsonOrders();
709            }
710
711            // Set CTE manager before building query
712            if ($this->cteManager !== null) {
713                $this->selectQueryBuilder->setCteManager($this->cteManager);
714            }
715
716            return $this->selectQueryBuilder->getColumn();
717        } finally {
718            $this->restoreConnection($originalConnection);
719        }
720    }
721
722    /**
723     * Execute SELECT statement and return single value.
724     *
725     * @return mixed
726     * @throws PDOException
727     */
728    public function getValue(): mixed
729    {
730        $originalConnection = $this->switchToReadConnection();
731
732        try {
733            // Integrate JSON selections before executing query
734            $jsonSelects = $this->jsonQueryBuilder->getJsonSelects();
735            if (!empty($jsonSelects)) {
736                $this->selectQueryBuilder->select($jsonSelects);
737                // Clear JSON selections after integration to avoid duplication
738                $this->jsonQueryBuilder->clearJsonSelects();
739            }
740
741            // Integrate JSON order expressions before executing query
742            $jsonOrders = $this->jsonQueryBuilder->getJsonOrders();
743            if (!empty($jsonOrders)) {
744                foreach ($jsonOrders as $orderExpr) {
745                    // JSON order expressions already contain direction, so add them directly
746                    $this->selectQueryBuilder->addOrderExpression($orderExpr);
747                }
748                // Clear JSON orders after integration to avoid duplication
749                $this->jsonQueryBuilder->clearJsonOrders();
750            }
751
752            // Set CTE manager before building query
753            if ($this->cteManager !== null) {
754                $this->selectQueryBuilder->setCteManager($this->cteManager);
755            }
756
757            return $this->selectQueryBuilder->getValue();
758        } finally {
759            $this->restoreConnection($originalConnection);
760        }
761    }
762
763    /* ---------------- DML: insert / update / delete / replace ---------------- */
764
765    /**
766     * Insert data into the table.
767     *
768     * @param array<string, string|int|float|bool|null|RawValue|array<string, string|int|float>> $data The data to insert.
769     * @param array<string, string|int|float|bool|null|RawValue> $onDuplicate The columns to update on duplicate.
770     *
771     * @return int The result of the insert operation.
772     */
773    public function insert(array $data, array $onDuplicate = []): int
774    {
775        $originalConnection = $this->switchToWriteConnection();
776
777        try {
778            return $this->dmlQueryBuilder->insert($data, $onDuplicate);
779        } finally {
780            $this->restoreConnection($originalConnection);
781        }
782    }
783
784    /**
785     * Insert multiple rows into the table.
786     *
787     * @param array<int, array<string, string|int|float|bool|null|RawValue>> $rows The rows to insert.
788     * @param array<string, string|int|float|bool|null|RawValue> $onDuplicate The columns to update on duplicate.
789     *
790     * @return int The result of the insert operation.
791     */
792    public function insertMulti(array $rows, array $onDuplicate = []): int
793    {
794        $originalConnection = $this->switchToWriteConnection();
795
796        try {
797            return $this->dmlQueryBuilder->insertMulti($rows, $onDuplicate);
798        } finally {
799            $this->restoreConnection($originalConnection);
800        }
801    }
802
803    /**
804     * Replace data into the table.
805     *
806     * @param array<string, string|int|float|bool|null|RawValue|array<string, string|int|float>> $data The data to replace.
807     * @param array<string, string|int|float|bool|null|RawValue> $onDuplicate The columns to update on duplicate.
808     *
809     * @return int The result of the replace operation.
810     */
811    public function replace(array $data, array $onDuplicate = []): int
812    {
813        $originalConnection = $this->switchToWriteConnection();
814
815        try {
816            return $this->dmlQueryBuilder->replace($data, $onDuplicate);
817        } finally {
818            $this->restoreConnection($originalConnection);
819        }
820    }
821
822    /**
823     * Replace multiple rows into the table.
824     *
825     * @param array<int, array<string, string|int|float|bool|null|RawValue>> $rows The rows to replace.
826     * @param array<string, string|int|float|bool|null|RawValue> $onDuplicate The columns to update on duplicate.
827     *
828     * @return int The result of the replace operation.
829     */
830    public function replaceMulti(array $rows, array $onDuplicate = []): int
831    {
832        $originalConnection = $this->switchToWriteConnection();
833
834        try {
835            return $this->dmlQueryBuilder->replaceMulti($rows, $onDuplicate);
836        } finally {
837            $this->restoreConnection($originalConnection);
838        }
839    }
840
841    /**
842     * Execute UPDATE statement.
843     *
844     * @param array<string, string|int|float|bool|null|RawValue|array<string, string|int|float>> $data
845     *
846     * @return int
847     * @throws PDOException
848     */
849    public function update(array $data): int
850    {
851        $originalConnection = $this->switchToWriteConnection();
852
853        try {
854            return $this->dmlQueryBuilder->update($data);
855        } finally {
856            $this->restoreConnection($originalConnection);
857        }
858    }
859
860    /**
861     * Execute DELETE statement.
862     *
863     * @return int
864     * @throws PDOException
865     */
866    public function delete(): int
867    {
868        $originalConnection = $this->switchToWriteConnection();
869
870        try {
871            return $this->dmlQueryBuilder->delete();
872        } finally {
873            $this->restoreConnection($originalConnection);
874        }
875    }
876
877    /**
878     * Execute TRUNCATE statement.
879     *
880     * @return bool
881     * @throws PDOException
882     */
883    public function truncate(): bool
884    {
885        return $this->dmlQueryBuilder->truncate();
886    }
887
888    /**
889     * Execute MERGE statement (INSERT/UPDATE/DELETE based on match conditions).
890     *
891     * @param string|\Closure(\tommyknocker\pdodb\query\QueryBuilder): void|SelectQueryBuilderInterface $source Source table/subquery for MERGE
892     * @param string|array<string> $onConditions ON clause conditions
893     * @param array<string, string|int|float|bool|null|RawValue> $whenMatched Update columns when matched
894     * @param array<string, string|int|float|bool|null|RawValue> $whenNotMatched Insert columns when not matched
895     * @param bool $whenNotMatchedBySourceDelete Delete when not matched by source
896     *
897     * @return int Number of affected rows
898     */
899    public function merge(
900        string|\Closure|SelectQueryBuilderInterface $source,
901        string|array $onConditions,
902        array $whenMatched = [],
903        array $whenNotMatched = [],
904        bool $whenNotMatchedBySourceDelete = false
905    ): int {
906        $originalConnection = $this->switchToWriteConnection();
907
908        try {
909            if ($this->table === null) {
910                throw new RuntimeException('Table must be set before calling merge()');
911            }
912            $this->dmlQueryBuilder->setTable($this->table);
913            $this->dmlQueryBuilder->setPrefix($this->prefix);
914            return $this->dmlQueryBuilder->merge($source, $onConditions, $whenMatched, $whenNotMatched, $whenNotMatchedBySourceDelete);
915        } finally {
916            $this->restoreConnection($originalConnection);
917        }
918    }
919
920    /* ---------------- Batch processing methods ---------------- */
921
922    /**
923     * Execute query and return iterator for batch processing.
924     *
925     * Processes data in batches of specified size, yielding arrays of records.
926     * Useful for processing large datasets without loading everything into memory.
927     *
928     * @param int $batchSize Number of records per batch (default: 100)
929     *
930     * @return Generator<int, array<int, array<string, mixed>>, mixed, void>
931     * @throws InvalidArgumentException If batch size is invalid
932     * @throws PDOException
933     */
934    public function batch(int $batchSize = 100): Generator
935    {
936        $query = $this->selectQueryBuilder->getQuery();
937        return $this->batchProcessor->batch($query['sql'], $query['params'], $batchSize);
938    }
939
940    /**
941     * Execute query and return iterator for individual record processing.
942     *
943     * Processes data one record at a time, but loads them from database in batches
944     * for efficiency. Useful when you need to process each record individually
945     * but want to avoid memory issues with large datasets.
946     *
947     * @param int $batchSize Internal batch size for database queries (default: 100)
948     *
949     * @return Generator<int, array<string, mixed>, mixed, void>
950     * @throws InvalidArgumentException If batch size is invalid
951     * @throws PDOException
952     */
953    public function each(int $batchSize = 100): Generator
954    {
955        $query = $this->selectQueryBuilder->getQuery();
956        return $this->batchProcessor->each($query['sql'], $query['params'], $batchSize);
957    }
958
959    /**
960     * Stream query results without loading into memory.
961     *
962     * Most memory efficient method for very large datasets. Uses database cursor
963     * to stream results row by row without loading them into memory. Best for simple
964     * sequential processing of large datasets.
965     *
966     * @return Generator<int, array<string, mixed>, mixed, void>
967     * @throws PDOException
968     */
969    public function stream(): Generator
970    {
971        $originalConnection = $this->switchToReadConnection();
972
973        try {
974            // Integrate JSON selections before executing query
975            $jsonSelects = $this->jsonQueryBuilder->getJsonSelects();
976            if (!empty($jsonSelects)) {
977                $this->selectQueryBuilder->select($jsonSelects);
978                // Clear JSON selections after integration to avoid duplication
979                $this->jsonQueryBuilder->clearJsonSelects();
980            }
981
982            // Integrate JSON order expressions before executing query
983            $jsonOrders = $this->jsonQueryBuilder->getJsonOrders();
984            if (!empty($jsonOrders)) {
985                foreach ($jsonOrders as $orderExpr) {
986                    // JSON order expressions already contain direction, so add them directly
987                    $this->selectQueryBuilder->addOrderExpression($orderExpr);
988                }
989                // Clear JSON orders after integration to avoid duplication
990                $this->jsonQueryBuilder->clearJsonOrders();
991            }
992
993            // Set CTE manager before building query
994            if ($this->cteManager !== null) {
995                $this->selectQueryBuilder->setCteManager($this->cteManager);
996            }
997
998            $query = $this->selectQueryBuilder->getQuery();
999            return $this->batchProcessor->stream($query['sql'], $query['params']);
1000        } finally {
1001            $this->restoreConnection($originalConnection);
1002        }
1003    }
1004
1005    /* ---------------- Conditions: where / having / logical variants ---------------- */
1006
1007    /**
1008     * Add WHERE clause.
1009     *
1010     * @param string|array<string, mixed>|RawValue $exprOrColumn The expression or column to add.
1011     * @param mixed $value The value to use in the condition.
1012     * @param string $operator The operator to use in the condition.
1013     *
1014     * @return static The current instance.
1015     */
1016    public function where(string|array|RawValue $exprOrColumn, mixed $value = null, string $operator = '='): static
1017    {
1018        // Handle the old signature: where(array $conditions, array $params)
1019        if (is_array($exprOrColumn) && is_array($value) && $operator === '=') {
1020            // This is the old signature: where(['age' => ':age'], ['age' => 30])
1021            foreach ($exprOrColumn as $column => $placeholder) {
1022                if (is_string($placeholder) && str_starts_with($placeholder, ':')) {
1023                    // Add the parameter to the parameter manager with the placeholder name
1024                    $this->parameterManager->setParam($placeholder, $value[$column] ?? null);
1025                    // Use whereRaw to handle the placeholder directly
1026                    $this->conditionBuilder->whereRaw("{$column} = {$placeholder}");
1027                } else {
1028                    // Regular array condition
1029                    $this->conditionBuilder->where($column, $placeholder);
1030                }
1031            }
1032            return $this;
1033        }
1034
1035        // Handle the new signature: where(string|array|RawValue $exprOrColumn, mixed $value, string $operator)
1036        $this->conditionBuilder->where($exprOrColumn, $value, $operator);
1037        return $this;
1038    }
1039
1040    /**
1041     * Add AND WHERE clause.
1042     *
1043     * @param string|array<string, mixed>|RawValue $exprOrColumn The expression or column to add.
1044     * @param mixed $value The value to use in the condition.
1045     * @param string $operator The operator to use in the condition.
1046     *
1047     * @return static The current instance.
1048     */
1049    public function andWhere(string|array|RawValue $exprOrColumn, mixed $value = null, string $operator = '='): static
1050    {
1051        $this->conditionBuilder->andWhere($exprOrColumn, $value, $operator);
1052        return $this;
1053    }
1054
1055    /**
1056     * Add OR WHERE clause.
1057     *
1058     * @param string|array<string, mixed>|RawValue $exprOrColumn The expression or column to add.
1059     * @param mixed $value The value to use in the condition.
1060     * @param string $operator The operator to use in the condition.
1061     *
1062     * @return static The current instance.
1063     */
1064    public function orWhere(string|array|RawValue $exprOrColumn, mixed $value = null, string $operator = '='): static
1065    {
1066        $this->conditionBuilder->orWhere($exprOrColumn, $value, $operator);
1067        return $this;
1068    }
1069
1070    /**
1071     * Add HAVING clause.
1072     *
1073     * @param string|array<string, mixed>|RawValue $exprOrColumn The expression or column to add.
1074     * @param mixed $value The value to use in the condition.
1075     * @param string $operator The operator to use in the condition.
1076     *
1077     * @return static The current instance.
1078     */
1079    public function having(string|array|RawValue $exprOrColumn, mixed $value = null, string $operator = '='): static
1080    {
1081        $this->conditionBuilder->having($exprOrColumn, $value, $operator);
1082        return $this;
1083    }
1084
1085    /**
1086     * Add OR HAVING clause.
1087     *
1088     * @param string|array<string, mixed>|RawValue $exprOrColumn The expression or column to add.
1089     * @param mixed $value The value to use in the condition.
1090     * @param string $operator The operator to use in the condition.
1091     *
1092     * @return static The current instance.
1093     */
1094    public function orHaving(string|array|RawValue $exprOrColumn, mixed $value = null, string $operator = '='): static
1095    {
1096        $this->conditionBuilder->orHaving($exprOrColumn, $value, $operator);
1097        return $this;
1098    }
1099
1100    /**
1101     * Add WHERE IN clause with subquery.
1102     *
1103     * @param string $column The column to check
1104     * @param callable(QueryBuilderInterface): void $subquery The subquery callback
1105     *
1106     * @return static The current instance
1107     */
1108    public function whereIn(string $column, callable $subquery): static
1109    {
1110        $this->conditionBuilder->whereIn($column, $subquery);
1111        return $this;
1112    }
1113
1114    /**
1115     * Add WHERE NOT IN clause with subquery.
1116     *
1117     * @param string $column The column to check
1118     * @param callable(QueryBuilderInterface): void $subquery The subquery callback
1119     *
1120     * @return static The current instance
1121     */
1122    public function whereNotIn(string $column, callable $subquery): static
1123    {
1124        $this->conditionBuilder->whereNotIn($column, $subquery);
1125        return $this;
1126    }
1127
1128    /**
1129     * Add WHERE EXISTS clause.
1130     *
1131     * @param callable(QueryBuilderInterface): void $subquery The subquery callback
1132     *
1133     * @return static The current instance
1134     */
1135    public function whereExists(callable $subquery): static
1136    {
1137        $this->conditionBuilder->whereExists($subquery);
1138        return $this;
1139    }
1140
1141    /**
1142     * Add WHERE NOT EXISTS clause.
1143     *
1144     * @param callable(QueryBuilderInterface): void $subquery The subquery callback
1145     *
1146     * @return static The current instance
1147     */
1148    public function whereNotExists(callable $subquery): static
1149    {
1150        $this->conditionBuilder->whereNotExists($subquery);
1151        return $this;
1152    }
1153
1154    /**
1155     * Add raw WHERE clause.
1156     *
1157     * @param string $sql The raw SQL condition
1158     * @param array<string, mixed> $params The parameters for the condition
1159     *
1160     * @return static The current instance
1161     */
1162    public function whereRaw(string $sql, array $params = []): static
1163    {
1164        $this->conditionBuilder->whereRaw($sql, $params);
1165        return $this;
1166    }
1167
1168    /**
1169     * Add raw HAVING clause.
1170     *
1171     * @param string $sql The raw SQL condition
1172     * @param array<string, mixed> $params The parameters for the condition
1173     *
1174     * @return static The current instance
1175     */
1176    public function havingRaw(string $sql, array $params = []): static
1177    {
1178        $this->conditionBuilder->havingRaw($sql, $params);
1179        return $this;
1180    }
1181
1182    /* ---------------- Existence helpers ---------------- */
1183
1184    /**
1185     * Return true if at least one row matches the current WHERE conditions.
1186     *
1187     * @return bool
1188     * @throws PDOException
1189     */
1190    public function exists(): bool
1191    {
1192        return $this->conditionBuilder->exists();
1193    }
1194
1195    /**
1196     * Return true if no rows match the current WHERE conditions.
1197     *
1198     * @return bool
1199     * @throws PDOException
1200     */
1201    public function notExists(): bool
1202    {
1203        return $this->conditionBuilder->notExists();
1204    }
1205
1206    /**
1207     * Checks if a table exists.
1208     *
1209     * @return bool True if the table exists, false otherwise.
1210     */
1211    public function tableExists(): bool
1212    {
1213        return $this->conditionBuilder->tableExists();
1214    }
1215
1216    /* ---------------- Joins ---------------- */
1217
1218    /**
1219     * Add JOIN clause.
1220     *
1221     * @param string $tableAlias Logical table name or table + alias (e.g. "users u" or "schema.users AS u")
1222     * @param string|RawValue $condition Full ON condition (either a raw SQL fragment or a plain condition string)
1223     * @param string $type JOIN type, e.g. INNER, LEFT, RIGHT
1224     *
1225     * @return static The current instance.
1226     */
1227    public function join(string $tableAlias, string|RawValue $condition, string $type = 'INNER'): static
1228    {
1229        $this->joinBuilder->join($tableAlias, $condition, $type);
1230        return $this;
1231    }
1232
1233    /**
1234     * Add LEFT JOIN clause.
1235     *
1236     * @param string $tableAlias Logical table name or table + alias (e.g. "users u" or "schema.users AS u")
1237     * @param string|RawValue $condition Full ON condition (either a raw SQL fragment or a plain condition string)
1238     *
1239     * @return static The current instance.
1240     */
1241    public function leftJoin(string $tableAlias, string|RawValue $condition): static
1242    {
1243        $this->joinBuilder->leftJoin($tableAlias, $condition);
1244        return $this;
1245    }
1246
1247    /**
1248     * Add RIGHT JOIN clause.
1249     *
1250     * @param string $tableAlias Logical table name or table + alias (e.g. "users u" or "schema.users AS u")
1251     * @param string|RawValue $condition Full ON condition (either a raw SQL fragment or a plain condition string)
1252     *
1253     * @return static The current instance.
1254     */
1255    public function rightJoin(string $tableAlias, string|RawValue $condition): static
1256    {
1257        $this->joinBuilder->rightJoin($tableAlias, $condition);
1258        return $this;
1259    }
1260
1261    /**
1262     * Add INNER JOIN clause.
1263     *
1264     * @param string $tableAlias Logical table name or table + alias (e.g. "users u" or "schema.users AS u")
1265     * @param string|RawValue $condition Full ON condition (either a raw SQL fragment or a plain condition string)
1266     *
1267     * @return static The current instance.
1268     */
1269    public function innerJoin(string $tableAlias, string|RawValue $condition): static
1270    {
1271        $this->joinBuilder->innerJoin($tableAlias, $condition);
1272        return $this;
1273    }
1274
1275    /**
1276     * Add LATERAL JOIN clause.
1277     *
1278     * LATERAL JOINs allow correlated subqueries in FROM clause,
1279     * where the subquery can reference columns from preceding tables.
1280     *
1281     * @param string|callable(\tommyknocker\pdodb\query\QueryBuilder): void $tableOrSubquery Table name or callable for subquery
1282     * @param string|RawValue|null $condition Optional ON condition
1283     * @param string $type JOIN type (default: LEFT)
1284     * @param string|null $alias Optional alias for LATERAL subquery
1285     *
1286     * @return static
1287     */
1288    public function lateralJoin(
1289        string|callable $tableOrSubquery,
1290        string|RawValue|null $condition = null,
1291        string $type = 'LEFT',
1292        ?string $alias = null
1293    ): static {
1294        $this->joinBuilder->lateralJoin($tableOrSubquery, $condition, $type, $alias);
1295        return $this;
1296    }
1297
1298    /* ---------------- Ordering / grouping / pagination / options ---------------- */
1299
1300    /**
1301     * Add ORDER BY clause.
1302     *
1303     * @param string|array<int|string, string>|RawValue $expr The expression(s) to order by.
1304     * @param string $direction The direction of the ordering (ASC or DESC).
1305     *
1306     * @return static The current instance.
1307     */
1308    public function orderBy(string|array|RawValue $expr, string $direction = 'ASC'): static
1309    {
1310        $this->selectQueryBuilder->orderBy($expr, $direction);
1311        return $this;
1312    }
1313
1314    /**
1315     * Add GROUP BY clause.
1316     *
1317     * @param string|array<int, string|RawValue>|RawValue $cols The columns to group by.
1318     *
1319     * @return static The current instance.
1320     */
1321    public function groupBy(string|array|RawValue $cols): static
1322    {
1323        $this->selectQueryBuilder->groupBy($cols);
1324        return $this;
1325    }
1326
1327    /**
1328     * Enable caching for this query.
1329     *
1330     * @param int $ttl Time-to-live in seconds
1331     * @param string|null $key Custom cache key (null = auto-generate)
1332     *
1333     * @return static The current instance.
1334     */
1335    public function cache(int $ttl = 3600, ?string $key = null): static
1336    {
1337        $this->selectQueryBuilder->cache($ttl, $key);
1338        return $this;
1339    }
1340
1341    /**
1342     * Disable caching for this query.
1343     *
1344     * @return static The current instance.
1345     */
1346    public function noCache(): static
1347    {
1348        $this->selectQueryBuilder->noCache();
1349        return $this;
1350    }
1351
1352    /**
1353     * Add LIMIT clause.
1354     *
1355     * @param int $number The number of rows to limit.
1356     *
1357     * @return static The current instance.
1358     */
1359    public function limit(int $number): static
1360    {
1361        $this->selectQueryBuilder->limit($number);
1362        $this->dmlQueryBuilder->setLimit($number);
1363        return $this;
1364    }
1365
1366    /**
1367     * Add OFFSET clause.
1368     *
1369     * @param int $number The number of rows to offset.
1370     *
1371     * @return static The current instance.
1372     */
1373    public function offset(int $number): static
1374    {
1375        $this->selectQueryBuilder->offset($number);
1376        return $this;
1377    }
1378
1379    /**
1380     * Sets the query options.
1381     *
1382     * @param string|array<int|string, mixed> $options The query options.
1383     *
1384     * @return static The current object.
1385     */
1386    public function option(string|array $options): static
1387    {
1388        $this->selectQueryBuilder->option($options);
1389
1390        // Also add options for DML operations
1391        $this->dmlQueryBuilder->addOption($options);
1392
1393        return $this;
1394    }
1395
1396    /**
1397     * Set fetch mode to return objects.
1398     *
1399     * @return static
1400     */
1401    public function asObject(): static
1402    {
1403        $this->selectQueryBuilder->asObject();
1404        return $this;
1405    }
1406
1407    /* ---------------- ON DUPLICATE / upsert helpers ---------------- */
1408
1409    /**
1410     * Add ON DUPLICATE clause.
1411     *
1412     * @param array<string, string|int|float|bool|null|RawValue> $onDuplicate The columns to update on duplicate.
1413     *
1414     * @return static The current instance.
1415     */
1416    public function onDuplicate(array $onDuplicate): static
1417    {
1418        $this->dmlQueryBuilder->onDuplicate($onDuplicate);
1419        return $this;
1420    }
1421
1422    /* ---------------- Introspect ---------------- */
1423
1424    /**
1425     * Convert query to SQL string and parameters.
1426     *
1427     * @param bool $formatted Whether to format SQL for readability
1428     *
1429     * @return array{sql: string, params: array<string, string|int|float|bool|null>}
1430     */
1431    public function toSQL(bool $formatted = false): array
1432    {
1433        // Set CTE manager before building SQL
1434        if ($this->cteManager !== null) {
1435            $this->selectQueryBuilder->setCteManager($this->cteManager);
1436        }
1437
1438        return $this->selectQueryBuilder->toSQL($formatted);
1439    }
1440
1441    /**
1442     * Execute EXPLAIN query to analyze query execution plan.
1443     *
1444     * @return array<int, array<string, mixed>>
1445     * @throws PDOException
1446     */
1447    public function explain(): array
1448    {
1449        return $this->selectQueryBuilder->explain();
1450    }
1451
1452    /**
1453     * Execute EXPLAIN ANALYZE query (PostgreSQL) or EXPLAIN FORMAT=JSON (MySQL).
1454     *
1455     * @return array<int, array<string, mixed>>
1456     * @throws PDOException
1457     */
1458    public function explainAnalyze(): array
1459    {
1460        return $this->selectQueryBuilder->explainAnalyze();
1461    }
1462
1463    /**
1464     * Analyze EXPLAIN output with optimization recommendations.
1465     *
1466     * @param string|null $tableName Optional table name for index suggestions
1467     *
1468     * @return \tommyknocker\pdodb\query\analysis\ExplainAnalysis Analysis result with recommendations
1469     */
1470    public function explainAdvice(?string $tableName = null): \tommyknocker\pdodb\query\analysis\ExplainAnalysis
1471    {
1472        return $this->selectQueryBuilder->explainAdvice($tableName);
1473    }
1474
1475    /**
1476     * Execute DESCRIBE query to get table structure.
1477     *
1478     * @return array<int, array<string, mixed>>
1479     * @throws PDOException
1480     */
1481    public function describe(): array
1482    {
1483        return $this->selectQueryBuilder->describe();
1484    }
1485
1486    /**
1487     * Get indexes for the current table.
1488     *
1489     * @return array<int, array<string, mixed>>
1490     */
1491    public function indexes(): array
1492    {
1493        return $this->selectQueryBuilder->indexes();
1494    }
1495
1496    /**
1497     * Get foreign keys for the current table.
1498     *
1499     * @return array<int, array<string, mixed>>
1500     */
1501    public function keys(): array
1502    {
1503        return $this->selectQueryBuilder->keys();
1504    }
1505
1506    /**
1507     * Get constraints for the current table.
1508     *
1509     * @return array<int, array<string, mixed>>
1510     */
1511    public function constraints(): array
1512    {
1513        return $this->selectQueryBuilder->constraints();
1514    }
1515
1516    /* ---------------- Execution primitives (pass-through helpers) ---------------- */
1517
1518    /**
1519     * Execute statement.
1520     *
1521     * @param string|RawValue $sql
1522     * @param array<int|string, string|int|float|bool|null> $params
1523     *
1524     * @return PDOStatement
1525     * @throws PDOException
1526     */
1527    public function executeStatement(string|RawValue $sql, array $params = []): PDOStatement
1528    {
1529        return $this->executionEngine->executeStatement($sql, $params);
1530    }
1531
1532    /**
1533     * Fetch all rows.
1534     *
1535     * @param string|RawValue $sql
1536     * @param array<int|string, string|int|float|bool|null> $params
1537     *
1538     * @return array<int, array<string, mixed>>
1539     * @throws PDOException
1540     */
1541    public function fetchAll(string|RawValue $sql, array $params = []): array
1542    {
1543        return $this->executionEngine->fetchAll($sql, $params);
1544    }
1545
1546    /**
1547     * Fetch column.
1548     *
1549     * @param string|RawValue $sql
1550     * @param array<int|string, string|int|float|bool|null> $params
1551     *
1552     * @return mixed
1553     * @throws PDOException
1554     */
1555    public function fetchColumn(string|RawValue $sql, array $params = []): mixed
1556    {
1557        return $this->executionEngine->fetchColumn($sql, $params);
1558    }
1559
1560    /**
1561     * Fetch row.
1562     *
1563     * @param string|RawValue $sql
1564     * @param array<int|string, string|int|float|bool|null> $params
1565     *
1566     * @return mixed
1567     * @throws PDOException
1568     */
1569    public function fetch(string|RawValue $sql, array $params = []): mixed
1570    {
1571        return $this->executionEngine->fetch($sql, $params);
1572    }
1573
1574    /* ---------------- CSV / XML loaders ---------------- */
1575
1576    /**
1577     * Loads data from a CSV file into a table.
1578     *
1579     * @param string $filePath The path to the CSV file.
1580     * @param array<string, mixed> $options The options to use to load the data.
1581     *
1582     * @return bool True on success, false on failure.
1583     */
1584    public function loadCsv(string $filePath, array $options = []): bool
1585    {
1586        return $this->fileLoader->loadCsv($filePath, $options);
1587    }
1588
1589    /**
1590     * Loads data from an XML file into a table.
1591     *
1592     * @param string $filePath The path to the XML file.
1593     * @param string $rowTag The tag that identifies a row.
1594     * @param int|null $linesToIgnore The number of lines to ignore at the beginning of the file.
1595     *
1596     * @return bool True on success, false on failure.
1597     */
1598    public function loadXml(string $filePath, string $rowTag = '<row>', ?int $linesToIgnore = null): bool
1599    {
1600        return $this->fileLoader->loadXml($filePath, $rowTag, $linesToIgnore);
1601    }
1602
1603    /**
1604     * Loads data from a JSON file into a table.
1605     *
1606     * @param string $filePath The path to the JSON file.
1607     * @param array<string, mixed> $options The options to use to load the data.
1608     *
1609     * @return bool True on success, false on failure.
1610     */
1611    public function loadJson(string $filePath, array $options = []): bool
1612    {
1613        return $this->fileLoader->loadJson($filePath, $options);
1614    }
1615
1616    /* ---------------- JSON methods ---------------- */
1617
1618    /**
1619     * Add SELECT expression extracting JSON value by path.
1620     *
1621     * @param string $col
1622     * @param array<int, string|int>|string $path
1623     * @param string|null $alias
1624     * @param bool $asText
1625     *
1626     * @return static
1627     */
1628    public function selectJson(string $col, array|string $path, ?string $alias = null, bool $asText = true): static
1629    {
1630        $this->jsonQueryBuilder->selectJson($col, $path, $alias, $asText);
1631        return $this;
1632    }
1633
1634    /**
1635     * Add WHERE condition comparing JSON value at path.
1636     *
1637     * @param string $col
1638     * @param array<int, string|int>|string $path
1639     * @param string $operator
1640     * @param mixed $value
1641     * @param string $cond
1642     *
1643     * @return static
1644     */
1645    public function whereJsonPath(
1646        string $col,
1647        array|string $path,
1648        string $operator,
1649        mixed $value,
1650        string $cond = 'AND'
1651    ): static {
1652        $this->jsonQueryBuilder->whereJsonPath($col, $path, $operator, $value, $cond);
1653        return $this;
1654    }
1655
1656    /**
1657     * Add WHERE JSON contains (col contains value).
1658     *
1659     * @param string $col
1660     * @param mixed $value
1661     * @param array<int, string|int>|string|null $path
1662     * @param string $cond
1663     *
1664     * @return static
1665     */
1666    public function whereJsonContains(
1667        string $col,
1668        mixed $value,
1669        array|string|null $path = null,
1670        string $cond = 'AND'
1671    ): static {
1672        $this->jsonQueryBuilder->whereJsonContains($col, $value, $path, $cond);
1673        return $this;
1674    }
1675
1676    /**
1677     * Update JSON field: set value at path (create missing).
1678     *
1679     * @param string $col
1680     * @param array<int, string|int>|string $path
1681     * @param mixed $value
1682     *
1683     * @return RawValue
1684     */
1685    public function jsonSet(string $col, array|string $path, mixed $value): RawValue
1686    {
1687        return $this->jsonQueryBuilder->jsonSet($col, $path, $value);
1688    }
1689
1690    /**
1691     * Remove JSON path from column (returns RawValue to use in update).
1692     *
1693     * @param string $col
1694     * @param array<int, string|int>|string $path
1695     *
1696     * @return RawValue
1697     */
1698    public function jsonRemove(string $col, array|string $path): RawValue
1699    {
1700        return $this->jsonQueryBuilder->jsonRemove($col, $path);
1701    }
1702
1703    /**
1704     * Add ORDER BY expression based on JSON path.
1705     *
1706     * @param string $col
1707     * @param array<int, string|int>|string $path
1708     * @param string $direction
1709     *
1710     * @return static
1711     */
1712    public function orderByJson(string $col, array|string $path, string $direction = 'ASC'): static
1713    {
1714        $this->jsonQueryBuilder->orderByJson($col, $path, $direction);
1715        return $this;
1716    }
1717
1718    /**
1719     * Check existence of JSON path (returns boolean condition).
1720     *
1721     * @param string $col
1722     * @param array<int, string|int>|string $path
1723     * @param string $cond
1724     *
1725     * @return static
1726     */
1727    public function whereJsonExists(string $col, array|string $path, string $cond = 'AND'): static
1728    {
1729        $this->jsonQueryBuilder->whereJsonExists($col, $path, $cond);
1730        return $this;
1731    }
1732
1733    /* ---------------- Pagination methods ---------------- */
1734
1735    /**
1736     * Paginate query results with metadata.
1737     *
1738     * @param int $perPage
1739     * @param int|null $page
1740     * @param array<string, mixed> $options
1741     *
1742     * @return pagination\PaginationResult
1743     * @throws PDOException
1744     */
1745    public function paginate(int $perPage = 15, ?int $page = null, array $options = []): pagination\PaginationResult
1746    {
1747        // Integrate JSON selections and orders before paginating
1748        $this->integrateJsonSelectionsAndOrders();
1749
1750        return $this->selectQueryBuilder->paginate($perPage, $page, $options);
1751    }
1752
1753    /**
1754     * Simple pagination without total count.
1755     *
1756     * @param int $perPage
1757     * @param int|null $page
1758     * @param array<string, mixed> $options
1759     *
1760     * @return pagination\SimplePaginationResult
1761     * @throws PDOException
1762     */
1763    public function simplePaginate(int $perPage = 15, ?int $page = null, array $options = []): pagination\SimplePaginationResult
1764    {
1765        // Integrate JSON selections and orders before paginating
1766        $this->integrateJsonSelectionsAndOrders();
1767
1768        return $this->selectQueryBuilder->simplePaginate($perPage, $page, $options);
1769    }
1770
1771    /**
1772     * Cursor-based pagination.
1773     *
1774     * @param int $perPage
1775     * @param string|pagination\Cursor|null $cursor
1776     * @param array<string, mixed> $options
1777     *
1778     * @return pagination\CursorPaginationResult
1779     * @throws PDOException
1780     */
1781    public function cursorPaginate(
1782        int $perPage = 15,
1783        string|pagination\Cursor|null $cursor = null,
1784        array $options = []
1785    ): pagination\CursorPaginationResult {
1786        // Integrate JSON selections and orders before paginating
1787        $this->integrateJsonSelectionsAndOrders();
1788
1789        return $this->selectQueryBuilder->cursorPaginate($perPage, $cursor, $options);
1790    }
1791
1792    /**
1793     * Integrate JSON selections and orders into select query builder.
1794     */
1795    protected function integrateJsonSelectionsAndOrders(): void
1796    {
1797        // Integrate JSON selections
1798        $jsonSelects = $this->jsonQueryBuilder->getJsonSelects();
1799        if (!empty($jsonSelects)) {
1800            $this->selectQueryBuilder->select($jsonSelects);
1801            $this->jsonQueryBuilder->clearJsonSelects();
1802        }
1803
1804        // Integrate JSON order expressions
1805        $jsonOrders = $this->jsonQueryBuilder->getJsonOrders();
1806        if (!empty($jsonOrders)) {
1807            foreach ($jsonOrders as $orderExpr) {
1808                $this->selectQueryBuilder->addOrderExpression($orderExpr);
1809            }
1810            $this->jsonQueryBuilder->clearJsonOrders();
1811        }
1812    }
1813}